Skip to content

Feat/llm retry report:SDK retry-attempt observability for review (#785) - #790

Open
Gongyl01 wants to merge 7 commits into
alibaba:mainfrom
Gongyl01:feat/llm-retry-report
Open

Feat/llm retry report:SDK retry-attempt observability for review (#785)#790
Gongyl01 wants to merge 7 commits into
alibaba:mainfrom
Gongyl01:feat/llm-retry-report

Conversation

@Gongyl01

@Gongyl01 Gongyl01 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #785.

Part of #368.

Adds a versioned, immutable RetryReport to ocr review that records the real HTTP attempts made inside the official Anthropic and OpenAI SDK retry loops. OCR currently sees only the final return value of a logical LLM request, so a request that receives 429, then 529, and finally succeeds is indistinguishable from a first-attempt success.

The report is frozen once after the review run and its background work have finished. Text output renders a compact attempt chain, while --format json exposes the same frozen value under the optional top-level retry_report field. A clean first-attempt-success run emits no report, so the existing output remains unchanged.

This is the observability slice of #368. It explains what the existing SDK retry loops did; it does not take ownership of retry policy.

  • internal/llm/retry_report.go: adds the ocr.llm-retry-report/v1 value model and a per-run, concurrency-safe RetryCollector. The collector derives attempt numbers and timings, decides request outcomes exactly once, validates aggregate invariants, and freezes requests in deterministic logical_request_id order.
  • internal/llm/retry_observer.go: mounts one shared Middleware implementation on Anthropic, OpenAI Chat Completions, and OpenAI Responses clients. It observes each real HTTP attempt without reading response bodies or overriding SDK decisions, recording status, provider request ID, server retry hints, x-should-retry, time to response headers, and the measured gap between attempts.
  • internal/llm/retry_boundary.go + client boundaries: correct attempts whose failure becomes visible only after HTTP 200, including unexpected EOF or malformed decoding, interrupted/incomplete SSE streams, and unsuccessful Responses object statuses. Every logical request finalizes on success, error, cancellation, or panic.
  • internal/llm/retry_meta.go + review call sites: stamps stable, non-secret identity on plan, main_task, memory compression, re-location, and review filter requests. The identity joins the report to existing session task records; scan and llm test remain outside the report.
  • cmd/opencodereview: freezes the collector at the same run boundary as the manifest and publishes the report exactly once across normal and failure exits. Terminal and JSON output consume the same immutable snapshot.

Commits (5 layered slices)

Commit Slice
6c7525c feat(llm): add retry report data layer
5b9509b feat(llm): observe retry attempts via SDK middleware
4145d13 feat(llm): correct attempts and finalize requests at the client boundary
8fec2a3 feat(llm): stamp request identity on review LLM requests
e41741e feat(cmd): publish the frozen retry report at the run boundary

Design (key invariants)

  • Observation only. The official SDK remains the retry owner. This PR does not change WithMaxRetries(5), SDK backoff or jitter, Retry-After behavior, or retry admission.
  • One logical request, one final outcome. Request outcome is decided from the complete attempt sequence, the logical call's return value, and the parent context: succeeded, recovered, failed, or cancelled. It is never inferred from the last attempt alone.
  • Observed facts, not guessed text. Attempt classification reads HTTP status and Go error types only. Raw provider error text is never parsed. A non-2xx status is authoritative; failures discovered after HTTP 200 are revised only when the boundary has typed evidence about the phase.
  • Exactly-once publication. The collector freezes only after Agent.Run has joined background work. Normal result and failure-usage paths cannot publish the same report twice, and an invariant violation suppresses the self-contradictory report instead of emitting partial data. Joining background work before session finalization also closes a pre-existing review-path race that could append llm_request/llm_response events after the session file was finalized.
  • Deterministic under concurrency. A per-run collector prevents state leakage between runs. Stable request identity, contiguous attempt numbering, and sorted logical_request_id output make concurrent runs reproducible.
  • Additive output contract. retry_report is optional and uses omitempty; a run with only clean first attempts keeps its previous terminal and JSON output.
  • Allowlisted diagnostics. The report may contain provider/model labels, file/task identity, status codes, provider request IDs, retry hints, and timings. It never contains credentials, authorization headers, prompts, request or response bodies, complete endpoint URLs, or raw provider error strings.

Attempt classification

error_class Evidence
rate_limited HTTP 429
overloaded HTTP 529
authentication HTTP 401/403
timeout HTTP 408/504 or an error matching context.DeadlineExceeded
network Transport failure or unexpected EOF
provider Other explicit non-2xx provider status, or a typed provider stream/status failure
cancelled An error matching context.Canceled, including explicit parent-context cancellation
unknown Stable fallback when a post-200 failure cannot be classified more specifically without parsing message text

Output contract

Example terminal output:

LLM retry report: 1/2 requests retried, 2 retries, 1 recovered, 1 failed, 0 cancelled
- internal/agent/agent.go / main_task #1: rate_limited(429) -> overloaded(529) -> success
- internal/llm/client.go / plan #1: authentication(401) -> failed

The JSON path is additive:

.retry_report.schema_version == "ocr.llm-retry-report/v1"

Failed and cancelled logical requests use separate failed_requests and cancelled_requests aggregates, while cancelled requests remain listed.

The terminal summary and retry_report are generated from the same frozen RetryReport; JSON mode emits one JSON document and never mixes in the terminal summary.

Upstream integration

This branch is synchronized through upstream/main@62e2b99. The retry report is published alongside #367's frozen run manifest without changing its coverage or terminal-state contract. Existing scan output, llm test, Resume/checkpoint behavior, budget reporting, and session persistence semantics remain unchanged.

How to test

make test
make vet

# A clean run keeps the existing contract: retry_report is absent.
ocr review --from main --to feature --format json | jq '.retry_report'

# On a run that encounters a retryable or terminal LLM failure,
# inspect the frozen report and its attempt chains.
ocr review --from main --to feature --format json | jq '.retry_report'

Checklist

  • make test passes locally (full non-extension package suite with -race -count=1)
  • make vet passes
  • git diff --check upstream/main...HEAD is clean
  • gofmt -l reports no changed Go files
  • Tests cover 429 recovery, 529 exhaustion, authentication behavior, timeout and cancellation semantics, transport and unexpected EOF recovery, post-200 decode/stream/status correction, panic finalization, concurrent collection, deterministic ordering, and exactly-once terminal/JSON publication
  • Clean first-attempt-success output is regression-locked with no retry_report
  • The emitted schema and terminal rendering are allowlist-tested against secret or raw provider content

Out of scope (deliberately)

@wu21-web

wu21-web commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Thank you, but this is big and hard to review.

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

OpenCodeReview: Review partially complete: 0 finding(s); 2 of 16 selected item(s) failed.

@Gongyl01

Gongyl01 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Thank you, but this is big and hard to review.

Hi @wu21-web, Thanks for the feedback! To help with the review, here's a breakdown — about 4,670 of the 6,197 added lines (~75%) are tests. The core implementation is only ~1,500 lines:

New core files (suggested reading order):

  1. internal/llm/retry_meta.go (+154) — retry metadata carried per request
  2. internal/llm/retry_boundary.go (+141) — retry boundary decisions
  3. internal/llm/retry_observer.go (+132) — observes retry attempts from the SDK
  4. internal/llm/retry_report.go (+658) — aggregates observations into the report

Integration points:

  • internal/llm/client.go (+97) and internal/llm/responses_client.go (+27) — hook observers into the SDK clients
  • internal/llmloop/loop.go (+70) — thread retry metadata through the loop
  • internal/agent/agent.go (+40) — propagate to agents
  • cmd/opencodereview/output.go (+79) and cmd/opencodereview/review_cmd.go (+32) — render the report

Gongyl01 and others added 6 commits August 10, 2026 17:22
Add the internal data layer for an explicit LLM request retry report: request
identity, attempt classification, and a per-run collector that freezes into an
immutable report. No behavior change — nothing is mounted on any client and no
output is produced, so this is inert until the observer is wired up.

- RequestMeta identifies one logical request (provider, model, file path, task
  type, request no) and travels through the request context, so the
  single-method LLMClient interface and every call site stay unchanged.
- logical_request_id is SHA-256 over a canonical NUL-terminated encoding of
  run_id plus the meta. It is computed in Freeze, so the collector can be
  constructed before the session exists.
- classifyAttempt derives error_class and failure_phase from the HTTP status
  and the Go error type only, never from error message text. A non-2xx status
  outranks the error, since it is the stronger fact.
- RetryCollector is created per run with no package-level state, is safe for
  concurrent use, and drops attempts that carry no identity, which is how scan
  and llm test requests stay out of the report.
- The request outcome is decided once, in Finalize, from the attempt sequence
  plus the returned error and the parent context state, rather than inferred
  from the last attempt: cancelling during backoff produces no new attempt, so
  the sequence still ends in an error while the outcome is cancelled.
- Freeze recomputes every aggregate from the listed requests and returns a
  construction error instead of publishing self-contradictory numbers. Ordering
  bugs (double Finalize, mutation after Finalize) are recorded as violations
  and surface there.

The report has no free-text field, so there is nothing to redact: no bodies,
prompts, URLs or raw SDK error strings. A test pins the exact set of plain
string fields so adding one has to be argued for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: 艺临 <gongyiling.gyl@alibaba-inc.com>
Mount a shared observer on all three LLM clients (Anthropic, OpenAI Chat
Completions, OpenAI Responses) through option.WithMiddleware, so every real
HTTP attempt the SDK retry loop makes is recorded against the logical request
that issued it.

The observer reads response headers only -- status code, request-id /
x-request-id, Retry-After (all three forms, at the SDK's own precedence),
x-should-retry -- and never touches the body, which the SDK owns and closes
before retrying. Attempts without a RequestMeta on the context are dropped
whole, which is how scan and `ocr llm test` stay out of the report.

RecordAttempt now takes the attempt's start and end timestamps instead of
pre-computed durations. observed_backoff_ms spans two attempts, so only the
collector can derive it; deriving both durations there also means the observer
cannot desynchronize numbering from the real call order. No clock abstraction
is needed and the values stay deterministic in tests.

The collector is reached through an unexported ClientConfig field rather than
new constructor parameters, keeping the three exported constructors unchanged.
It is created per run in loadLLMRuntime, not package-level, so two runs in one
process cannot share data. Nothing consumes it yet -- P5 calls Freeze at the
run boundary.

The roadmap's X-Stainless-Retry-Count cross-check is deliberately not
implemented: the SDK stops maintaining that header once ExtraHeaders overrides
it, so the mismatch branch is only reachable from a legitimate configuration,
and the desync it guards against is already caught at build time by the
exhaustion and recovery tests asserting exact attempt counts.

WithMaxRetries(5) and WithRequestTimeout are untouched; the SDK's retry
decisions are observed, never overridden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The middleware can only observe real HTTP attempts, so an HTTP 200 that
carried a truncated body, undecodable JSON, a mid-stream failure, or a dead
Responses object was recorded as a success. Each client now corrects its last
attempt before returning and finalizes the logical request exactly once.

- add retry_boundary.go: classifyBoundaryError (unrecognized errors are left
  alone rather than bucketed as unknown, since the only way left to tell them
  apart would be message text), classifyStreamError, reviseAttempt,
  finalizeRequest, streamIntegrityError and the panic sentinel
- defer the boundary on all three CompletionsWithCtx, which now use named
  results; correction runs before Finalize, as the reverse order would be a
  "revised after Finalize" violation and drop the whole run's report
- correct both EOF branches ahead of their ctx early return, so a parent
  cancel between the two SDK calls cannot leave a truncated attempt as success
- split completionsStreaming into a wrapper with a single exit, so the four
  inner returns need no correction call of their own
- replace the three bare fmt.Errorf stream integrity errors with a dedicated
  type, messages unchanged
- parentCancelled reads only context.Canceled: the per-attempt deadline from
  WithRequestTimeout must surface as failed, not as a user abort
- drop finalizeForTest from the observer tests; every case now reaches Freeze
  through a client, so a missing defer fails that case instead of passing

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
review 的五类逻辑请求在调用 SDK 前建立 RequestMeta,使 observer 能按请求身份收集 attempt;scan 的六类请求保持无 meta、不进报告。

- Deps 增加 NewRequestMeta 工厂字段:review 在 agent.New 注入闭包,scan 保持 nil;不用空 provider 当开关,空串是 unnamed endpoint 的合法值
- main_task / memory compression / re-location / plan / review filter 五个落点遵循固定顺序:AppendTaskRecord -> requestCtx -> 请求
- compression 的记录创建移到请求之前,使 request_no 在请求发起时即存在;orphan llm_request 对 resume 无害(applyResumeLine 无该分支),补回归断言
- ReLocateComment 拆出纯 prompt 构造 BuildReLocationMessages,internal/diff 不接触 session / meta;Duration 口径保持含 prompt 构造时间不变
- 导出 RequestMetaFromContext,供 llmloop / agent / scan 三包的测试跨包验收请求身份
在 review 运行边界冻结重试报告并经两个出口发布;scan 与 llm test 输出不变,session JSONL 与 run manifest 契约不动。

- Runner 增加后台 WaitGroup 与 WaitBackground():agent.Run 在 dispatchSubtasks 之后、finalizeManifest 之前收口 async compression,消除 Freeze 见到未 Finalize 请求而吞掉整份报告的竞态;不加第二个超时,等待依赖 SDK 遵守取消契约
- review_cmd.go 在 ag.Run 返回后调用 Freeze,run_id 取 session 内存 UUID 而非持久化门控的 SessionID();构造错误并入 emitErr 而非 runErr,不包装成 review failed、不触发失败 usage、不打 --resume 提示
- 报告以末位参数传给 emitRunResult / outputJSONWithWarnings,不扩展 ResultProvider;双出口去重:emitRunResult 已执行时 emitFailureUsage 不重复携带
- 终端摘要走 stdout,位于评审结果与项目摘要之间,全量渲染不截断,file_path / task_type 经 sanitizeTerminal 防控制字符注入
- JSON 在 jsonOutput 末位追加 retry_report(omitempty),直接复用 llm.RetryReport 的字段与 tag;首次成功运行输出逐字节不变
- 端到端:假 Anthropic server + 真 git 仓库驱动 runReview,覆盖干净运行、recovered+failed、全失败去重、Freeze 构造错误、session 持久化失败五个场景;manual_e2e tag 保留写码前的手工验证夹具
The retry-report tests for alibaba#368 P5 split coverage of emitRunResult and
emitFailureUsage into their own file, leaving the review-run emit
functions tested in two places. Move those emit-boundary cases into
emit_run_result_test.go beside the pre-existing emitRunResult tests, and
rename the remaining file to retry_report_render_test.go so it holds only
the report-rendering cases (outputRetryReportText, the JSON key-set
allowlist, retryAttemptChain). The shared retryReportFixture stays with
the rendering tests; both files are package main so it is still reachable.

No test logic changes; only relocation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Gongyl01
Gongyl01 force-pushed the feat/llm-retry-report branch from e41741e to 66fcd2b Compare August 10, 2026 09:47
@lizhengfeng101

Copy link
Copy Markdown
Collaborator

Really solid piece of work — thanks for the layered commits and for writing down the invariants instead of just asserting them in the PR description. A few notes from going through it carefully.

Things I verified rather than assumed, since they're load-bearing for the whole design:

  • The middleware genuinely sits inside the SDK retry loop. requestconfig.go:419-423 builds handler from the middleware chain, and res, err = handler(req) is called at line 449 inside for retryCount := 0; retryCount <= cfg.MaxRetries. Same shape in openai-go v3. So one observer invocation per real HTTP attempt holds.
  • shouldRetry (requestconfig.go:248) does consult x-should-retry ahead of the status code and does not exclude 2xx — your OutcomeSucceeded comment about "nothing was recovered from" is describing a real case, not a hypothetical.
  • The per-attempt timeout path returns the derived ctx's DeadlineExceeded, while finalizeRequest reads the parent ctx for parentCancelled. So an attempt-level timeout correctly lands on failed rather than cancelled, exactly as your design note claims.
  • go build, go vet, and go test -race -count=1 across internal/{llm,llmloop,diff,agent,scan,session} + cmd/opencodereview all pass on my machine.

The one thing I'd like to see changed: cancelled is counted in failed_requests

retry_report.go:528 folds OutcomeCancelled into FailedRequests, and RetryReport has no cancelled_requests field. That matters more than it looks, because cancelling an in-flight background compression isn't an edge case — it's a designed-for, routine event. Beyond the deferred cancelPendingCompression at loop.go:237, there are two in-loop call sites (loop.go:569 and loop.go:590): whenever the async job hasn't landed by the time the conversation crosses the warning threshold, you cancel it and fall back to synchronous compression.

The full chain: asyncCtx = WithTimeout(WithoutCancel(ctx), 5min)cancel() → the in-flight request aborts, http.Client.Do returns (nil, *url.Error{Err: context.Canceled}) → the observer records an attempt with StatusCode == 0 classified cancelled/contextfinalizeRequest sees ctx.Err() == context.CanceledOutcomeCancelledFailedRequests++, and it gets listed because outcome != succeeded.

Net effect: a run with zero retries and zero genuine failures can print

LLM retry report: 0/12 requests retried, 0 retries, 0 recovered, 1 failed
- internal/foo.go / memory_compression #1: cancelled -> cancelled

The classification itself is right — it really was cancelled. It's the aggregate label that's wrong, and cancelled -> cancelled reads oddly too (the attempt has no status code, so retryAttemptChain emits the bare class and then appends the request outcome). I'd add cancelled_requests and keep failed_requests for OutcomeFailed only. A user-initiated Ctrl-C on main_task is still worth surfacing, so this is about splitting the counter, not hiding anything.


Two latent things — both unreachable today, so entirely your call

I'm flagging these as code-level inconsistencies, not as bugs. I checked reachability on both and couldn't get either to fire in production.

freezeErr reaches the process exit code. review_cmd.go:284 returns errors.Join(emitErr, freezeErr), which lands on main.go:27's os.Exit(1). On a fully successful run, stdout would carry a complete "status": "success" document while the process exits non-zero — awkward for CI. That said, I walked every Freeze error exit and none of them is reachable: runID is always non-empty (session.New unconditionally does sessionID := generateUUID(), history.go:151); the violation strings need either duplicate RequestMeta identity (impossible — RequestNo is allocated under fs.mu, scoped per (FilePath, TaskType)) or an attempt after the boundary defer (impossible — both the EOF re-call and the streaming path complete before it); non-2xx without a classification can't happen because observeAttempt is the only production caller and always classifies; and not finalized is closed off by WaitBackground. Your own var newRetryCollector hook exists for exactly this reason, so we agree on the facts. My only suggestion is that a pure-observability invariant breach probably shouldn't be able to fail an otherwise-successful review — a stderr warning would be a softer landing. Not a blocker either way.

isErrorStatus vs the SDKs' >= 400. retry_report.go:172 treats anything outside 200–299 as proof of failure, while both SDKs use res.StatusCode >= 400. I initially thought a 3xx would show up as a false error attempt and flip a successful request to recovered, but that doesn't hold: both SDKs populate GetBody (requestconfig.go:401 / :434), so http.Client follows 301/302/303/307/308 and the middleware only ever sees the post-redirect response. The statuses Go won't follow (300/304/305/306) don't occur against an LLM API — no conditional requests are sent, and Go rejects 305 outright. And even if a 300 did arrive, the SDK would try to JSON-decode it, fail, and classifyBoundaryError would land the request on failed, not a bogus recovered. So: real divergence, no reachable consequence. Might still be worth pinning to >= 400, or adding a line explaining why you're deliberately stricter than the SDK, since the "a non-2xx status is authoritative" framing in the PR body reads as if it matches the SDK boundary when it doesn't.


Small stuff

  • WaitBackground fixes more than the PR claims credit for. Because it runs ahead of finalizeManifest() and session.Finalize(), a background compression goroutine can no longer write llm_request/llm_response lines after the session file has been finalized. That was a pre-existing race on the review path and you've closed it — worth a sentence in the PR body, since a reviewer skimming for "why is this needed" will only see the retry-report justification.
  • internal/scan still has that race. scan/agent.go:335,371 call a.session.Finalize() but nothing calls WaitBackground. Out of scope here, and the nil NewRequestMeta comment is clear about why scan opts out of the report — but a line in WaitBackground's doc saying "scan deliberately doesn't join, since it freezes no report" would stop the next reader from thinking it's an oversight.
  • Comment parity on re-location. compression.go:230 explains the record-before-request reordering and its resume implication in detail. loop.go:470 makes the same reordering but only explains the cm.Path choice. Behaviour is equivalent (I diffed the old and new msgs semantics — BuildReLocationMessages returns nil for exactly the cases the old early return covered), so this is purely a cross-reference. Also worth noting that rlStart now includes prompt construction and AppendTaskRecord in TaskRecord.Duration — negligible, but the comment says moving it would change what Duration measures, when leaving it in place changed it slightly too.
  • Docs. The top-level field table in pages/src/content/docs/{en,ja,ru,zh}/cli-reference.md doesn't list retry_report. It's already missing manifest from Emit a complete immutable review manifest and partial-coverage contract #367, so this isn't new debt you're creating, and omitempty keeps the JSON contract additive — but the terminal block is unconditionally user-visible. Deferring until Add trusted resume with explicit provider and model transition lineage #786/Add provider-specific retry budgets and a shared retry coordinator #787 land is a reasonable call; I'd just open a tracking issue so the backlog doesn't grow another entry.

One retraction on my side: I first questioned outputRetryReportText(os.Stdout, ...) versus the stdout.Writer() used elsewhere. That was wrong — newQuietHandle silences the stdout package's progress writer, not os.Stdout, and result output should bypass it exactly the way outputTextWithWarnings and the project summary already do. Your version is correct.

Overall: the only change I'd ask for before merge is splitting cancelled_requests out of failed_requests. Everything else is comments, docs, or optional tightening.

@Gongyl01

Copy link
Copy Markdown
Contributor Author

Thanks @lizhengfeng101 for the detailed review. I addressed the merge-blocking point in 5f99e5f: cancelled_requests is now separate from failed_requests, cancelled requests remain visible, and the duplicated cancelled -> cancelled rendering is removed. I also updated the related validation, tests, listing-rule comment, and the PR body’s WaitBackground race note. The two unreachable latent issues and the other optional items remain unchanged to keep this follow-up minimal. make check and the full race-enabled make test pass locally.

@lizhengfeng101 lizhengfeng101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exit code concern: freeze error surfaces as a non-zero exit on a successful review

In review_cmd.go, the final return is now:

return errors.Join(emitErr, freezeErr)

When the review itself fully succeeds but Freeze detects an invariant violation (un-finalized request), the CLI exits non-zero with "freeze retry report: ...". The review result is already published to stdout — the JSON is valid and complete — but the exit code says failure.

I understand this is deliberate (the E2E test asserts it) and the condition is practically unreachable in production (every CompletionsWithCtx finalizes in its defer, and WaitBackground joins all goroutines). The intent is to surface internal bugs early.

However, this breaks the principle that observability must not affect the system it observes. A downstream CI pipeline using ocr review || fail would fail for a reason unrelated to review quality, with no actionable remediation for the user.

Suggestion: downgrade freeze failure to a warning on stderr and return the review result cleanly:

if freezeErr != nil {
    fmt.Fprintf(os.Stderr, "[ocr] warning: %v (retry report suppressed)\n", freezeErr)
    // do not join into the return error
}

If the team prefers to keep it as a hard error for internal-bug detection, consider at minimum:

  1. Document it in the CLI reference as a "publish error" distinct from "review failed"
  2. Give the exit code a distinct value (e.g. exit 2 vs exit 1) so CI can distinguish

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose official SDK LLM retry attempts in review output

3 participants